| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202 |
- import './style.scss';
- import '@/app/styles/stock-common.scss';
- import { cache } from 'react';
- import { Metadata } from 'next';
- import Link from 'next/link';
- import { notFound } from 'next/navigation';
- import { fetchStockDetail, fetchStockHistory } from '@/lib/api/stock';
- import { changeDirection, changeSymbol, formatChangeRate, formatKrwCompact, formatNumber, tradingStatusLabel } from '@/lib/utils/stock';
- import Pager from '../_components/Pager';
- const TABS = [
- { value: 'price', label: '시세' },
- { value: 'news', label: '뉴스' },
- { value: 'disclosure', label: '공시' },
- { value: 'discussion', label: '토론' }
- ];
- const HISTORY_PER_PAGE = 30;
- type Props = {
- params: Promise<{
- code: string;
- }>;
- searchParams: Promise<{
- tab?: string;
- page?: string;
- }>;
- };
- // generateMetadata + 본문 이중 호출 방지 (요청 단위 dedupe)
- const getDetail = cache(async (code: string) => {
- return await fetchStockDetail(code);
- });
- export async function generateMetadata({ params }: Props): Promise<Metadata>
- {
- const { code } = await params;
- const res = await getDetail(code);
- if (!res.success || !res.data) {
- return {
- title: '종목 상세 — 개미투자'
- };
- }
- return {
- title: `${res.data.name}(${res.data.code}) 주가·시세 — 개미투자`,
- description: `${res.data.name}(${res.data.code}) 전일 종가·등락률·거래량·일별 시세 — 전일 종가 기준(T+1)`
- };
- }
- export default async function StockDetailPage({ params, searchParams }: Props)
- {
- const { code } = await params;
- const query = await searchParams;
- const tab = TABS.some(c => c.value === query.tab) ? (query.tab as string) : 'price';
- const page = Math.max(Number(query.page) || 1, 1);
- const res = await getDetail(code);
- // 없는 코드(형식 불일치 포함)는 404
- if (!res.success && res.status === 404) {
- return notFound();
- }
- // 백엔드 미기동 등 그 외 실패 — 에러 UI 로 우아하게 처리
- if (!res.success || !res.data) {
- return (
- <div id='stock-detail'>
- <div className='stock-detail__error' role='alert'>
- <strong>종목 정보를 불러오지 못했습니다.</strong>
- 잠시 후 다시 시도해 주세요.
- </div>
- </div>
- );
- }
- const detail = res.data;
- const latest = detail.recentPrices.length > 0 ? detail.recentPrices[0] : null;
- const direction = changeDirection(detail.changeRate);
- const statusLabel = tradingStatusLabel(detail.tradingStatus);
- // 시세 탭만 히스토리 조회 (request-time)
- const history = tab === 'price' ? await fetchStockHistory(code, { page, perPage: HISTORY_PER_PAGE }) : null;
- return (
- <div id='stock-detail'>
- {/* 상단 요약 — T+1 데이터임을 명시 */}
- <header className='stock-detail__summary'>
- <div className='stock-detail__title'>
- <h1>{detail.name}</h1>
- <span className='stock-detail__code'>{detail.code}</span>
- <span className={`stock-badge stock-badge--${detail.market.toLowerCase()}`}>{detail.market}</span>
- {statusLabel && (
- <span className={`stock-badge stock-badge--${detail.tradingStatus.toLowerCase()}`}>{statusLabel}</span>
- )}
- {detail.sectorName && <span className='stock-detail__sector'>{detail.sectorName}</span>}
- </div>
- <div className='stock-detail__price'>
- <strong className={`stock-detail__close stock-change stock-change--${direction}`}>
- {formatNumber(detail.closePrice)}
- </strong>
- <span className={`stock-detail__diff stock-change stock-change--${direction}`}>
- {changeSymbol(direction)} {latest ? formatNumber(Math.abs(latest.changeAmount)) : '—'} ({formatChangeRate(detail.changeRate)})
- </span>
- </div>
- <dl className='stock-detail__meta'>
- <div>
- <dt>거래량</dt>
- <dd>{formatNumber(latest?.volume)}</dd>
- </div>
- <div>
- <dt>거래대금</dt>
- <dd>{formatKrwCompact(latest?.tradingValue)}</dd>
- </div>
- <div>
- <dt>시가총액</dt>
- <dd>{formatKrwCompact(detail.marketCap)}</dd>
- </div>
- </dl>
- <p className='stock-detail__basis'>
- 전일 기준 · {detail.lastTradingDate ?? '데이터 준비 중'} 종가 (T+1 반영)
- </p>
- </header>
- {/* 탭 */}
- <nav className='stock-detail__tabs' aria-label='종목 상세 탭'>
- {TABS.map(item => (
- <Link
- key={item.value}
- href={item.value === 'price' ? `/stock/${detail.code}` : `/stock/${detail.code}?tab=${item.value}`}
- {...(tab === item.value ? { 'aria-current': 'page' as const } : {})}
- >
- {item.label}
- </Link>
- ))}
- </nav>
- <section className='stock-detail__panel'>
- {tab !== 'price' ? (
- <p className='stock-detail__placeholder'>준비 중입니다.</p>
- ) : !history || !history.success || !history.data ? (
- <div className='stock-detail__error' role='alert'>
- <strong>일별 시세를 불러오지 못했습니다.</strong>
- 잠시 후 다시 시도해 주세요.
- </div>
- ) : history.data.list.length === 0 ? (
- <p className='stock-detail__empty'>수집된 일별 시세가 없습니다.</p>
- ) : (
- <>
- <table className='stock-detail__table'>
- <caption>{detail.name} 일별 시세 — 날짜, 종가, 등락, 시가, 고가, 저가, 거래량, 거래대금</caption>
- <thead>
- <tr>
- <th scope='col'>날짜</th>
- <th scope='col'>종가</th>
- <th scope='col'>등락률</th>
- <th scope='col' className='stock-detail__col--sub'>시가</th>
- <th scope='col' className='stock-detail__col--sub'>고가</th>
- <th scope='col' className='stock-detail__col--sub'>저가</th>
- <th scope='col'>거래량</th>
- <th scope='col' className='stock-detail__col--sub'>거래대금</th>
- </tr>
- </thead>
- <tbody>
- {history.data.list.map(row => {
- const rowDirection = changeDirection(row.changeRate);
- return (
- <tr key={row.tradingDate}>
- <td>{row.tradingDate}</td>
- <td>{formatNumber(row.close)}</td>
- <td className={`stock-change stock-change--${rowDirection}`}>
- {changeSymbol(rowDirection)} {formatChangeRate(row.changeRate)}
- </td>
- <td className='stock-detail__col--sub'>{formatNumber(row.open)}</td>
- <td className='stock-detail__col--sub'>{formatNumber(row.high)}</td>
- <td className='stock-detail__col--sub'>{formatNumber(row.low)}</td>
- <td>{formatNumber(row.volume)}</td>
- <td className='stock-detail__col--sub'>{formatKrwCompact(row.tradingValue)}</td>
- </tr>
- );
- })}
- </tbody>
- </table>
- <Pager
- total={history.data.total}
- page={page}
- perPage={HISTORY_PER_PAGE}
- basePath={`/stock/${detail.code}`}
- query={{}}
- />
- </>
- )}
- </section>
- </div>
- );
- }
|